How to customize the Order Website view models using resource strings, ASP.NET MVC view models, ViewBag, and Knockout.js view models.
Overview
AtomiaStore makes data available to views in a few different forms:
- Resource strings: Static (localized) text. See Resource Strings and Localization
- ASP.NET MVC view models: E.g. forms. See below.
- ViewBag: Mainly data used across multiple views. See Customizing the Order Website Order Flow for an example.
- Knockout.js view models: JavaScript view models for interactive behavior populated via JSON rendered on page load, e.g. the cart. See below.
ASP.NET MVC View Models
Standard ASP.NET MVC view models are used mostly for handling forms. They let us leverage the built-in functionality for form validation and model binding.
The view model for each page type in the order flow can be extended or replaced:
- Atomia.Store.AspNetMvc.Models.AccountViewModel
- Atomia.Store.AspNetMvc.Models.CheckoutViewModel
- Atomia.Store.AspNetMvc.Models.DomainsViewModel
- Atomia.Store.AspNetMvc.Models.ProductListingViewModel
The related Atomia.Store.AspNetMvc.Models.ProductListingModel
can also be extended.
AccountViewModel
and the CheckoutViewModel
are abstract classes with the following default implementations:
- Atomia.Store.AspNetMvc.Models.DefaultAccountViewModel
- Atomia.Store.AspNetMvc.Models.DefaultCheckoutViewModel
Defining a New View Model
In the following example the default AccountViewModel
is extended with a sub-form for an optional Professional Survey of your customers, in addition to the default main contact and billing contact forms.
- If you used the
startnewtheme.ps1
script to bootstrap your theme, you should already have aModels
directory. Add a new file in this directory with the classesSurveyModel
andMyAccountViewModel
:
using Atomia.Store.AspNetMvc.Models; using Atomia.Store.Core; using Atomia.Web.Plugin.Validation.ValidationAttributes; // 1. namespace MyTheme.Models { public class SurveyModel : ContactData // 2. { public override string Id { get { return "Survey"; } } // 3. [AtomiaRequired("Common,ErrorEmptyField")] // 4. public string JobTitle { get; set; } public string Department { get; set; } // 5. } public class MyAccountViewModel : DefaultAccountViewModel // 6. { public MyAccountViewModel() : base() { this.Survey = new SurveyModel(); // 7. } public SurveyModel Survey { get; set; } // 8. } }
- The
Atomia.Web.Plugin.Validation
library has some specific model validation attributes that properly handle error messages as defined in theme resource files. It is not available by default in the bootstrapped theme, so you need to add the reference to the assemblyMyTheme\Lib\Atomia.Web.Plugin.Validation.dll
- The subform that you will use to collect the data subclasses
Atomia.Store.Core.ContactData
so you can later access it an add it to the order you place in Atomia Billing. - A
ContactData
implementation must have anId
property. It is convenient to have it be the same as the name of the subform property in theAccountViewModel
, in this case Survey. - In this example, you do not want to require the customer to fill in the survey, but if they choose to do so, you want to require that they supply a job title. You also re-use an error message that already exists in the default
resx
files. Department
is an optional field.- Since you just want to add an extra form, and not completely reimplement the
AccountViewModel
, you sub-class the existingDefaultAccountViewModel
. - For the new
SurveyModel
to be available in the view you need to instantiate it whenMyAccoutViewModel
is instantiated. - Since you did not want to require the user to supply survey data, configure this by not requiring the
SurveyModel
subform. If you POST the form without any of theSurveyModel
fields, the ASP.NET MVC model binder will ignore the subform and not generate any validation errors for it, but if you POST any of the fields from the subform the model validation will be triggered.
You have now defined the new view model, but it is not used in your Razor views yet.
There are two steps to adding the new model to the view.
- Registering the model with the dependency resolver.
- Setting the model as the
@model
in the view or appropriate partial view.
Registering the Model With the Dependency Resolver
AtomiaStore leverages Unity and the ASP.NET MVC DependencyResolver
to make many parts of the application replacable and extendable via dependency injection or service location.
By default the DefaultAccountViewModel
is registered to be provided when an instance of AccountViewModel
is needed.
You can override this registration either programmatically in App_Start\UnityConfig.cs
or with configuration in the unity
section of Web.config
.
How to do it in UnityConfig:
public class UnityConfig { public static void RegisterComponents(UnityContainer container) { container.RegisterType<AccountViewModel, MyTheme.Models.MyAccountViewModel>(); } }
How to do it in Web.config:
<unity xmlns="http://schemas.microsoft.com/practices/2010/unity"> ... <alias alias="AccountViewModel" type="Atomia.Store.AspNetMvc.Models.AccountViewModel, Atomia.Store.AspNetMvc" /> <alias alias="MyAccountViewModel" type="MyTheme.Models.MyAccountViewModel, MyTheme" /> ... <container> ... <register type="AccountViewModel" mapTo="MyAccountViewModel" /> ... </container> </unity>
Using the Model in the View
Having prepared the new view model you should now present it to the user by rendering it as a form in the view.
When you have a new view model implementation there are a few things to take into account when deciding how to use it in the views:
- Is the view model a standalone implementation or a sub-class of the default model?
- How extensive are the changes?
- Are there any existing partials that can contain the changes or does the “page” view need to be overridden?
In this case you have sub-classed the DefaultAccountViewModel
instead of re-implementing your own AccountViewModel
from scratch. This means you do not need to change any of the views that are using DefaultAccountViewModel
as their view model since your view model is still of that type. You have also only added to the default implementation, not overridden or hidden any of the existing members, so existing partial views that use the model properties can be left as is.
The Account/Index.cshtml
view has a couple of pre-defined extension points that can be used to add things without a need to override the whole view to add new partials: Account/_ExtraForms.cshtml
and Account/_ExtraScripts.cshtml
.
In this example you want to keep as much of the Default theme as possible to get the benefit of future updates to it, so you decide that you can add your new form to the Account/_ExtraForms.cshtml
. This is done by adding the file Themes/MyTheme/Views/Account/_ExtraForms.cshtml
with the following markup:
<div class="pro-survey"> // 2. <h4>@Html.CommonResource("ProfessionalSurvey")</h4> // 3. @Html.FormRowFor(m => m.Survey.JobTitle, Html.CommonResource("JobTitle") + ":", true) // 4. @Html.FormRowFor(m => m.Survey.Department, Html.CommonResource("Department") + ":", false) // 5. </div>
Index.cshtml
renders the_ExtraForms.cshml
partial with the whole view model like this:Html.RenderPartial("_ExtraForms", Model);
Use your newly defined class to strongly type the view so that you can access
Survey
and its members.- Wrap the new markup in a
div
to be able to style it, perhaps by floating it to one side or something else. - Add a title for your new sub-form that matches the titles for Contact Info and Billing Address. You also need to add the resource string
"ProfessionalSurvey"
toApp_GlobalResources/MyTheme/MyThemeCommon.resx
and any localizations you need. - Use an HTML helper from the Default theme to render the input field and label. It renders label, input field, and validation messages in the standard markup used for form fields in the Default theme. The last boolean argument
true
marks this field as required. You also need to add"JobTitle"
toApp_GlobalResources/MyTheme/MyThemeCommon.resx
for the label. - The same as 4, except this field is not required.
You should now have a basic form added for your survey. It will also have client-side as well as backend validation of the required JobTitle
field. However, you are not completely fulfilling your initial requirements yet since the survey is currently not optional and the JobTitle
field always will be required. In the next section you will see how this can be fixed by working with the knockout.js view model for the page.
Knockout.js View Models
AtomiaStore uses knockout.js view models for more interactive elements of the user interface. As with the backend code, these can also be reused and extended in different ways.
Continuing with your Professional Survey example from above, you want to make the entire sub-form optional, similar to how it is optional fill in the Default Billing Contact form.
The existing knockout view models on the Account page are all instantiated in the Themes/Default/Views/Account/_Scripts.cshtml
partial view. You can see there that there is an existing Atomia.ViewModels.AccountModel
that is instantiated as Atomia.VM.account
. This is a knockout view model that is used for showing or hiding the Billing Contact form and to control if the fields from that form are posted to the server or not, and to customize some fields depending on if the customer type is "individual"
or "company"
.
For your new Professional Survey form you have the choice to either extend the existing Atomia.VM.account
model or to add a separate knockout view model. In general, whether you choose to extend an existing knockout view model or create a new one depends on what you want to accomplish.
In the below example you will do both. You start with a basic knockout view model that is independent of the existing functionality and then make it dependent on some of the existing account model’s functionality and change to an extension model.
In both cases you put your new knockout view model code in Themes/MyTheme/Scripts/mytheme.viewmodels.survey.js
. The prepared setup on theme creation makes sure that all JavaScript files in the Themes/MyTheme/Scripts
directory are included in the defult scripts bundle.
Creating a New Knockout.js View Model
You start by defining a simple knockout view model using a variant of the module pattern an placing it under the MyTheme.ViewModels
namespace:
var MyTheme = MyTheme || {}; MyTheme.ViewModels = MyTheme.ViewModels || {}; // 1. (function (exports, _, ko) { // 2. 'use strict'; function ProfessionalSurveyModel() { var self = this; // 3. self.wantsToFillOutSurvey = ko.observable(true); // 4. self.optOutOfSurvey = function () { // 5. self.wantsToFillOutSurvey(false); }; self.optInToSurvey = function () { // 5. self.wantsToFillOutSurvey(true); }; } _.extend(exports, { // 6. ProfessionalSurveyModel: ProfessionalSurveyModel }); })(MyTheme.ViewModels, _, ko); // 7.
- Define the
MyTheme
andMyTheme.ViewModels
namespaces, if they don’t already exist. - The namespace the module
exports
to and underscore.js and knockout.js dependencies. - Use the common knockout.js pattern of assigning
this
toself
so you don’t need to bindthis
to our functions. (see Managing ‘this’ in knockout’s Computed Observables documentation) - Use the knockout observable to keep track if the customer wants to fill out the survey or not. Set it to
true
to begin with, so the customer has to opt out of filling it in. - Bind functions to clicking a link to let the user opt in or opt out of taking part in the survey.
- Extend the
MyTheme.ViewModels
namespace with yourProfessionalSurveyModel
constructor. The actual namespace and dependencies that are used in 2. - Make an instance of your
ProfessionalSurveyModel
view model inAccount/_ExtraScripts.cshtml
:<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" data-wp-preserve="%3Cscript%3E%0A%20%20%20%20Atomia.VM.survey%20%3D%20new%20MyTheme.ViewModels.ProfessionalSurveyModel()%3B%0A%3C%2Fscript%3E" data-mce-resize="false" data-mce-placeholder="1" class="mce-object" width="20" height="20" alt="<script>" title="<script>" />
- The
ProfessionalSurveyModel
is instantiated as a sub-model of theAtomia.VM
view model.Atomia.VM
and all sub-models will be activated on the page via a call toko.applyBindings(Atomia.VM)
. - Now you just need to set up the bindings between your
Atomia.VM.survey
model and the markup you added toAccount/_ExtraForms.cshtml
above: -
@model MyTheme.Models.MyAccountViewModel <div class="pro-survey" data-bind="with: survey"> // 1. <h4> @Html.CommonResource("ProfessionalSurvey") <span data-bind="visible: !wantsToFillOutSurvey()" style="display:none;"> // 2. (<a href="javascript:void(0);" data-bind="click: optInToSurvey">Sure, I'll take the survey</a>) </span> <span data-bind="visible: wantsToFillOutSurvey"> // 3. (<a href="javascript:void(0);" data-bind="click: optOutOfSurvey">No thanks!</a>) </span> </h4> <div data-bind="slideVisible: wantsToFillOutSurvey"> // 4. @Html.FormRowFor(m => m.Survey.JobTitle, Html.CommonResource("JobTitle") + ":", true, "if: wantsToFillOutSurvey") // 5. @Html.FormRowFor(m => m.Survey.Department, Html.CommonResource("Department") + ":", false, "if: wantsToFillOutSurvey") // 5. </div> </div>
- Set the scope of the contained data bindings to
survey
(short forAtomia.VM.survey
.) - Hide the opt-in link to start, and set up the
click
binding to opt in to the survey. - Show the the opt-out link, and set up the
click
binding to opt out of the survey. - Use the custom
slideVisible
binding, which is the same as the standardvisible
, except it slides down for a more pleasant experience. - For both form rows, set up an
if
binding to keep theinput
bindings in the DOM only if the user wants to fill out the survey. This has the effect of these fields not being posted on form submit if the user has opted out, and subsequently the ASP.NET MVC model binder will not try to bind these fields and skip validation ofJobTitle
even though it is annotated as required.
Extending an Existing Knockout.js View Model
You now have a functioning survey. However, you might want to handle it differently depending on if the customer is an individual or a company. So let’s add a requirement that if the customer is an individual the survey is opt-in and if the customer is a company the survey is opt-out.
- Access the
mainContactCustomerType
on theAtomia.VM.account
model, which is created via theAtomia.ViewModels.AccountModel
constructor. You want the start value of thewantsToFillOutSurey
to depend on themainContactCustomerType
. You also want to open the survey if the customer changes customer type to"company"
. - Modify the initialization of the survey to change
Atomia.VM.account
instead of creating a separate model. Here you use theAtomia.Utils.mix
method to combine the two constructors’AccountModel
andProfessionalSurveyModel
to create a single view model object. When you mix like this the properties declared byAccountModel
are available to theProfessionalSurveyModel
constructor. (Please note that the constructor itself is the argument, not an object created by the constructor, and that only constructors without arguments are supported.)
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" data-wp-preserve="%3Cscript%3E%0A%20%20%20%20Atomia.VM.account%20%3D%20Atomia.Utils.mix(%0A%20%20%20%20%20%20%20%20Atomia.ViewModels.AccountModel%2C%0A%20%20%20%20%20%20%20%20MyTheme.ViewModels.ProfessionalSurveyModel)%3B%0A%3C%2Fscript%3E" data-mce-resize="false" data-mce-placeholder="1" class="mce-object" width="20" height="20" alt="<script>" title="<script>" />
- Modify the
ProfessionalSurveyModel
constructor to work with some of theAccountModel
properties:
var MyTheme = MyTheme || {}; MyTheme.ViewModels = MyTheme.ViewModels || {}; (function (exports, _, ko) { 'use strict'; function ProfessionalSurveyModel() { var self = this; self.wantsToFillOutSurvey = ko.observable(self.mainContactIsCompany()); // 1. self.optOutOfSurvey = function () { self.wantsToFillOutSurvey(false); }; self.optInToSurvey = function () { self.wantsToFillOutSurvey(true); }; self.mainContactCustomerType.subscribe(function(newCustomerType){ // 2. if (newCustomerType === 'company') { self.wantsToFillOutSurvey(true); } }); } _.extend(exports, { ProfessionalSurveyModel: ProfessionalSurveyModel }); })(MyTheme.ViewModels, _, ko);
- Set up the start value of the survey to
true
if the customer is"company"
. - Subscribe to changes on the
mainContactCustomerType
property to setwantsToFillOutSurvey
totrue
if the customer is a"company"
. - The markup defined for the survey should work almost without change. Since setup a binding context with the
with
binding you only need to change that so that the markup binds to theAtomia.VM.account
model that you are extending, and not the now non-existingAtomia.VM.survey
model:
<div class="pro-survey" data-bind="with: account"> ... </div>
In the example above you only used some of the properties from the AccountModel
, but by the nature of JavaScript you might just as easily have redefined some properties as well.